0. 乐观锁

读多写少

乐观锁是一种乐观思想,意思就是认为读操作多而写操作少,遇到并发进行写操作的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会进行上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出版本号,然后再加锁操作(比较跟上一次的版本号,如果一样则进行更新),如果比较失败则要重复读 - 比较 - 写的操作。

CAS算法中含有三个操作数:内存地址—V、旧的预期值—A、更新的目标值—B

Java中的乐观锁基本上都是通过CAS(Compare And Swap算法)操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

1. 悲观锁

写操作多

悲观锁是一种悲观思想,意思就是认为写操作多,遇到并发写操作的可能性搞,每次拿数据的时候都会认为别人会修改,所以在每次读写数据的时候都会上锁,这样当别人想读写这个数据就会Block,这时就需要等待。

Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到才会转换为悲观锁,如RetreenLock。

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度,在Java与数据库中都存在着这两个概念。

img

以下为乐观锁与悲观锁的调用方式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 悲观锁
public synchronized void testMethod(){
// 操作同步资源
}

// 保证多个线程使用同一个锁
private ReentrantLock lock = new ReentrantLock();
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}

// 乐观锁
// 需要保证多个线程使用同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
public void increment() {
// 执行自增
int num = atomicInteger.incrementAndGet();
}

通过调用示例,可以发现悲观锁基本上都是在显示地上锁后再操作同步资源,而乐观锁则是直接可以操作同步资源,其乐观锁内部实际上就是通过CAS算法来保证资源的同步性。

2. 自旋锁

用于避免用户线程和内核的切换消耗

如果持有锁的线程能在很短的时间内释放锁资源,实际上等待竞争锁的线程就不需要做用户态和内核态之间的切换而进入阻塞挂起的状态,这些线程只需要等待一会(自旋),等持有锁的线程释放锁后就可以立即获得锁,而不需要多次进行用户线程和内核的切换

线程自旋则是需要消耗CPU的,实际上就是让CPU一直做着无用功,时间长了的话效果并不是很好,会出现占用CPU且无法获取锁的情况,这时就需要设定一个自旋等待的最大时间

如果持有锁的线程执行的时间超过了自旋等待的最大时间仍然还没有释放锁,就会导致其他争用锁的线程在最大等待时间内还获取不到锁,这时自旋线程会进入常规阻塞状态

a. 优 / 缺点

自旋锁尽可能地减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说型讷讷感大幅度地提升,因为自旋的消耗 < 线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换

线程上下文:指某一时间点 CPU 寄存器和程序计数器的内容,CPU通过时间片分配算法来循环执行任务(线程),因为时间片非常短,所以CPU通过不停地切换线程执行。

自旋锁的缺点非常明显,就是它无法代替阻塞,实际上它只是避免了线程状态切换过程中的开销,而占用CPU的时间与等待的时间依旧如此,但如果它占用CPU的时间短,那么这段由此自旋避免的开销时间将会带来极大的增益效果。反之,如果锁被占用的时间很长,实际上自旋的线程会浪费处理器资源,如果自旋超过了限定的次数(默认为10次,可通过-XX:PreBlockSpin修改),当超过此参数值还没由成功获得锁,则会使用传统的线程挂起方式来等待锁释放。

b. 时间阈值

JDK1.6 引入了适应性自旋锁

JDK1.7 去除且更改为JVM自控

JVM对于自旋周期的选择,在JDK1.5这个周期阈值是固定的,在JDK1.6中引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有者的状态来决定,基本上认为一个线程上下文切换的时间是一个最佳的时间,同时JVM还针对当前CPU的负荷情况做了较多的优化,如果平均负载小于CPUs(线程上下文切换的时间)则一直自旋,如果由超过(CPUs / 2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果CPU处于节电模式则停止自旋,自旋时间最坏的情况是CPU的存储延迟(CPU-A存储了一个数据,到CPU-B得知这个数据的直接时间差),自旋时会适当放弃线程优先级之间的差异。

c. 自旋锁的开启

  • JDK1.6中可使用-XX: +UseSpinning开启
    • 可使用-XX:PreBlockSpin=10设置自旋次数
  • JDK1.7后,去除了上述参数,更改为由JDK自控

3. 同步锁(Synchronized)

由于同一进程的多个线程共享同一片存储空间,在带来方便的同时,也带来了访问冲突这个严重的问题。Java语言提供了专门机制(synchronize关键字就是其中之一)以解决这种冲突,有效避免了同一个数据对象被多个线程同时访问。

当获取了多个锁时,它们必须以相反的顺序释放,且必须在与所有锁被获取时相同的词法范围内释放所有锁。

synchronized可以把任意一个非null的对象当作锁,它属于独占式的悲观锁,同时属于可重入锁

a. 作用

  • 原子性:保证句内的语句块操作是原子性的
  • 可见性:通过执行unlock前,先把此变量同步回主内存中实现可见性
  • 有序性:通过一个变量在同一时刻只允许一条线程对其进行lock操作保证有序性

b. 范围

  • 作用于方法时,锁住的是对象的实例(this)
  • 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据保存于永久代PermGen(JDK1.8 中为metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁定调用该方法的线程
  • synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它含有多个队列,当多个线程一起访问某个对象监视器时,对象监视器会将这些线程存储在不同的容器中

c. 实现

Java对象头与Monitor是实现synchronized的基础。

Java对象头

Mark Word 用于存储对象自身的运行时数据,如哈希值(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,它是实现轻量级锁和偏向锁的关键

Klass Pointer对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

HotSpot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)和Klass Pointer(类型指针)

Monitor

可以将其理解为是一个同步工具,也可以描述为是一种同步机制,它通常被描述为对象监视器

当多个线程同时请求某个对象监视器时,对象监视器会设置集中状态来区分请求的线程,如下所示:

  • WaitSet:调用wait方法被阻塞的线程被放置在此处
  • ConnectionList:竞争队列,所有请求锁的线程首先放置在这个竞争队列中
  • EntryList:Connection List中那些有资格成为候选资源的线程被移动到Entry List中
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程称为OnDeck
  • Owner:获得锁的线程称为Owner
  • !Owner:释放锁的线程

新请求锁的线程将首先被加入到ConnectionList中,当某个拥有锁的线程(Owner状态)调用unlock之后,如果发现EntryList为空则从ConnectionList中移动线程到EntryList。

ContentionList虚拟队列

ContentionList并不是一个真正的Queue,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个先进先出(FIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock-Free的队列。
因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。

EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在Hotspot中把OnDeck的选择行为称之为“竞争切换”。

OnDeck线程获得锁后即变为owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify()notifyAll()唤醒,则再次转移到EntryList。

  • 处于ConnectionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)
  • Synchronized是非公平锁Synchronized在线程进入ContentionList前,等待的线程会先尝试自旋获取锁,如果获取不到才会进入ContentionList,这明显对已经进入队列的线程是不公平的,同时自旋获取锁的线程可能之间抢占OnDeck线程的锁资源
  • Synchronized在JDK1.6前是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的实践比有用操作消耗的实践更多
  • JDK1.6开始,对锁的实现引入了大量的优化,如自旋锁适应性自旋锁锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,在之后的JDK1.7和JDK1.8中,均对该关键字的实现机制做了优化,引入了偏向锁和轻量级锁,都是在对象头有标记位,不需要经过操作系统加锁
  • 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,升级过程称为锁膨胀
  • JDK1.6中默认开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLocking禁用偏向锁

d. 缺陷

若将一个大的方法声明为synchronized将会大大地影响执行效率。比如:将线程类的方法run()声明为synchronized,由于在线程的整个生命周期内它都一直在运行,因此将会导致它对本类任何synchronized方法的调用都永远不会成功。

解决方案

通过synchronized关键字来声明synchronized方法块即可:

1
2
3
synchronized(syncObject) {
// 访问或修改被锁保护的共享状态
}

其中的代码必须获得对象syncObject(类实例或类)的锁才能执行,由于可以针对任意代码块,且可以任意地指定上锁对象,所以这个解决方案相对较灵活。

4. 可重入锁(ReentrantLock)

ReentantLock继承了Lock接口并实现了其接口内定义的方法,它是一种可重入锁,除了能完成synchronized所能完成的所有工作以外,还能提供可响应中断锁可轮询锁请求定时锁避免多线程死锁的方法。

例如:存在A、B两个线程进行锁竞争,A线程得到了锁,B线程进行等待,但是A线程此时阻塞,一直不进行锁返回,B线程在等不及的时候,想进行等待中断,此时ReentrantLock提供了两种机制,第一种是B线程中断自己*(或者其他线程进行中断)*,但是ReentrantLock不去响应第二种就是让B线程中断自己*(或者其他线程进行中断)*,ReentrantLock处理了这个中断,并且不再等待锁的释放

a. Lock接口主要方法

  • void lock():执行此方法时,如果锁处于空闲状态,当前线程将获取到锁。相反,如果锁已经被其他线程持有,将禁用当前线程,直到当前线程获取到锁。
  • boolean tryLock():如果锁可用,则获取锁,并返回true,否则返回false,该方法和lock()的区别在于,tryLock()方法只是试图获取锁,如果锁不可用,不会导致当前线程被禁用,当前线程仍然可以继续向下执行代码,而lock()方法则是一定要获取到锁,如果锁不可用,就会一直进行等待,在未获得到锁之前,当前线程不会继续向下执行。
  • void unlock():执行此方法时,当前线程将释放持有的锁,锁只能由持有者释放,如果线程并不持有锁,却执行该方法,则可能会导致异常发生。
  • Condition newCondition()条件对象,获取等待通知组件,该组件和当前的锁绑定。当前线程只有获取了锁,才能调用该组件的await()方法,而调用后,当前线程将释放锁。

b. 非公平锁

JVM按照随机、就近原则进行锁分配的机制成为非公平锁。其中ReentrantLock提供了是否使用公平锁的构造函数,默认为非公平锁,非公平锁的实际执行效率远超过公平锁,除非有特殊需求,否则一般常用非公平锁的分配机制。

1
new ReentrantLock(boolean fair)

c. 公平锁

公平锁指的是锁的分配机制是公平的。通常先对锁提出获取请求的线程会先分配到锁。

d. ReentrantLock与synchronized的区别

  • synchronized是托管给JVM执行的,而ReentrantLock是Java实际编写的控制锁代码。
  • ReentrantLock是Lock的实现类,是一个互斥的同步器,在多线程高竞争的条件下,ReentrantLock比sychronized有更加优异的性能表现
  • ReentrantLock只适用与代码块锁,而sychronized可用于修饰方法、代码块等。
  • ReentrantLock通过方法lock()unlock()来进行加锁和解锁的操作,与synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作
  • 使用ReentrantLock类时,不能将获取锁的过程写在try代码块中,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故在finally块中被释放。
  • ReentrantLock提供了一个newCondition()的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作。

e. Condition

Condition将Object监视器方法(wait、notify和notifyAll)分解为不同的对象,以便这些对象能与任意的Lock进行组合使用,为每个对象提供多个等待Set(wait-set)。其中,Lock代替了synchronized方法和语句的使用,Condition则代替了Object监视器方法的使用。

Condition与Object锁方法区别

  • Condtion类的await()方法和Object类的wait()方法等效
  • Condition类的signal()方法和Object类的notify()方法等效
  • Condition类的signalAll()方法和Object类的notifyAll()方法等效
  • ReentrantLock类可以唤醒指定条件的线程,而Object类的唤醒是随机的

5. 信号量(Semaphore)

Semaphore是一种基于计数的信号量,它可以设定一个阈值,多个线程竞争获取许可信号,做完自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞。Semaphore可以用来构建对象池或资源池。

用法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
// 创建一个计数阈值为5的信号量对象
// 表示只能五个线程同时访问
Semaphore s = new Semaphore(5);
try {
// 申请许可
s.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放许可
s.release();
}

a. Semaphore与ReentrantLock的区别

  • Semaphore基本能完成ReentrantLock的所有工作,使用方法也与之类似,通过acquire()release()方法来活得和释放临界资源。
  • Semaphore.acquire()方法默认为可响应中断锁,与ReetrantLock.lockInterruptibly()作用效果一致,也就是说在等待临界资源的时候可以由Thread.interrupt()方法进行中断。
  • Semaphore也实现了可轮询的锁请求与定时锁的功能,仅有方法名tryAcquire()tryLock()不同,其使用方法与ReetrantLock几乎一致。
  • Semaphore的锁释放操作为手动进行释放,因此与ReentrantLock一样,为了避免线程因抛出异常而无法正常释放锁的情况发生,锁释放的操作必须在finally代码块中进行。

6. AtomicInteger

AtomicInteger是一个提供原子操作的Integer的类,常见的还有AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference等,他们的原理相同,区别在于运算对象类型的不同,还可以通过AtomicReference<V>将一个对象的所有操作转换为原子操作。

在多线程程序中,++i, i++等运算不具有原子性,是不安全的线程操作之一,因此通常会使用synchronized将该操作变成一个原子操作,但JVM为此类操作提供了一些同步类,使得使用更方便,且使程序运行效率变得更高,AtomicInteger的性能是ReentrantLock的好几倍。

7. 读写锁(ReadWriteLock)

为了提高性能,Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,但读锁与写锁互斥,这是由JVM内部自行控制的。

在使用ReetrantReadWriteLock实现锁机制前,先看一下,多线程同时读取文件时,用synchronized实现的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public synchronized static void get(Thread thread) {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ":正在进行读操作……");
}
System.out.println(thread.getName() + ":读操作完毕!");
}

public static void main(String[] args) {
new Thread(() -> get(Thread.currentThread())).start();
new Thread(() -> get(Thread.currentThread())).start();
}


// 执行结果
// Thread-0:正在进行读操作……
// Thread-0:正在进行读操作……
// Thread-0:正在进行读操作……
// Thread-0:正在进行读操作……
// Thread-0:正在进行读操作……
// Thread-0:读操作完毕!
// Thread-0-耗时:105ms
// Thread-1:正在进行读操作……
// Thread-1:正在进行读操作……
// Thread-1:正在进行读操作……
// Thread-1:正在进行读操作……
// Thread-1:正在进行读操作……
// Thread-1:读操作完毕!
// Thread-1-耗时:105ms

执行结果可以看出,两个线程的读操作是顺序执行的,总耗时210ms。

使用ReetrantReadWriteLock读写锁实现的效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

public static void getByRWLock(Thread thread) {
long start = System.currentTimeMillis();
lock.readLock().lock();
for(int i = 0; i <= 5; ++i) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ": 正在进行读操作...");
}
System.out.println(thread.getName() + ":读操作完毕");
System.out.println(thread.getName() + "-耗时:" + (System.currentTimeMillis() - start) + "ms");
lock.readLock().unlock();
}


// 执行结果
// Thread-0: 正在进行读操作...
// Thread-1: 正在进行读操作...
// Thread-0: 正在进行读操作...
// Thread-1: 正在进行读操作...
// Thread-0: 正在进行读操作...
// Thread-1: 正在进行读操作...
// Thread-0: 正在进行读操作...
// Thread-1: 正在进行读操作...
// Thread-0: 正在进行读操作...
// Thread-1: 正在进行读操作...
// Thread-0: 正在进行读操作...
// Thread-0:读操作完毕
// Thread-0-耗时:125ms
// Thread-1: 正在进行读操作...
// Thread-1:读操作完毕
// Thread-1-耗时:126ms

通过上述两个比较可以得出,两个线程的读操作是同时执行的,整个过程合并越126ms,由此得出,ReentrantReadWriteLock的效率明显高于Synchronized关键字

8. 相关概念

a. 锁消除

为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法

1
2
3
4
5
6
7
8
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}

System.out.println(vector);
}

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

b. 锁粗化

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

c. 锁的等级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,它们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。